了解,昨天已經介紹過 GridView+自訂 Adapter+ViewHolder,今天不應再重複。DAY 24 改成「資料模型重構」,把原本容易出錯的 MutableMap<String, Any> 改成 Kotlin data class,並加入選取狀態與點擊反白效果。
參考資料:https://kotlinlang.org/docs/data-classes.html
昨天我們完成了 GridView 與自訂 Adapter,成功將圖片與名稱顯示成網格。不過程式目前使用 MutableMap<String, Any> 保存資料,雖然可以執行,但容易出現 Key 拼錯、強制轉型失敗及維護困難等問題。
參考資料:https://kotlinlang.org/docs/collections-overview.html
今天不再介紹 GridView 的基本用法,而是把昨天的程式進行一次正式重構:
data class 建立資料模型。"name"、"pic" 字串 Key。as Int 強制型別轉換。nameArray.indices 改寫迴圈。參考資料:https://kotlinlang.org/docs/data-classes.html
昨天使用下面的方式保存資料:
val data = mutableMapOf<String, Any>()
data["name"] = nameArray[i]
data["pic"] = picArray.getResourceId(
i,
R.drawable.flower1
)
取出資料時則需要使用:
val name = item["name"].toString()
val pictureResId = item["pic"] as Int
這種寫法存在三個問題。第一,"name" 和 "pic" 都只是普通字串,編譯器無法檢查拼字;第二,Value 使用 Any,取出資料時必須自行轉型;第三,資料欄位愈多,Map 的內容就愈難閱讀。
參考資料:https://kotlinlang.org/docs/typecasts.html
例如不小心寫成:
val pictureResId = item["picture"] as Int
程式仍然可能通過編譯,但執行時因為 "picture" 不存在,取到的結果是 null,接著強制轉成 Int 就可能發生例外。
參考資料:https://kotlinlang.org/docs/null-safety.html
我們可以建立一個 FlowerItem,明確定義每一筆資料包含哪些欄位:
package com.example.listview6
import androidx.annotation.DrawableRes
/**
* GridView 每一個花朵項目的資料模型。
*
* @property name 顯示在圖片下方的花朵名稱。
* @property pictureResId Drawable 圖片資源 ID。
*/
data class FlowerItem(
val name: String,
@DrawableRes
val pictureResId: Int
)
data class 適合用來保存資料。Kotlin 會自動產生 equals()、hashCode()、toString()、componentN() 及 copy() 等常用功能,不需要自己撰寫大量重複程式。
參考資料:https://kotlinlang.org/docs/data-classes.html
@DrawableRes 用來標示 pictureResId 應該接收 Drawable 資源 ID。它不會改變資料型別,但 Android Studio 可以利用這項資訊進行靜態檢查。
參考資料:https://developer.android.com/reference/androidx/annotation/DrawableRes
| 比較項目 | MutableMap 寫法 | Data Class 寫法 |
|---|---|---|
| 名稱欄位 | item["name"] |
item.name |
| 圖片欄位 | item["pic"] as Int |
item.pictureResId |
| 拼字檢查 | 編譯器無法檢查 | 編譯階段即可檢查 |
| 型別安全 | 使用 Any |
每個欄位型別明確 |
| 程式可讀性 | Key 多時較混亂 | 資料結構清楚 |
| 自動完成 | 幾乎沒有 | Android Studio 可提示 |
| 後續維護 | 容易寫錯 Key | 修改欄位時容易追蹤 |
使用資料模型後,原本的:
holder.itemTextView.text =
item["name"].toString()
holder.itemImageView.setImageResource(
item["pic"] as Int
)
可以改成:
holder.itemTextView.text = item.name
holder.itemImageView.setImageResource(
item.pictureResId
)
新版不再需要字串 Key,也不需要 as Int 強制轉型,程式碼更短且更安全。
參考資料:https://kotlinlang.org/docs/properties.html
今天除了重構資料模型,也在 Adapter 中增加 selectedPosition,記錄目前被選取的網格位置:
private var selectedPosition: Int = -1
初始值設為 -1,代表尚未選取任何項目。使用者點擊某一格後,再把點擊位置保存起來。
參考資料:https://developer.android.com/reference/android/widget/AdapterView.OnItemClickListener
fun setSelectedPosition(position: Int) {
selectedPosition = position
notifyDataSetChanged()
}
notifyDataSetChanged() 會通知 GridView:Adapter 的資料或顯示狀態已經改變,需要重新取得目前畫面上的項目 View。
參考資料:https://developer.android.com/reference/android/widget/BaseAdapter#notifyDataSetChanged()
在 res/drawable 建立以下兩個檔案,分別代表一般狀態與選取狀態。
參考資料:https://developer.android.com/guide/topics/resources/drawable-resource
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 一般狀態背景 -->
<solid android:color="#FFFFFFFF" />
<!-- 一般狀態使用淺灰色細框 -->
<stroke
android:width="1dp"
android:color="#FFE0E0E0" />
<!-- 圓角 -->
<corners android:radius="12dp" />
<!-- 項目內容與邊框之間的距離 -->
<padding
android:left="8dp"
android:top="8dp"
android:right="8dp"
android:bottom="8dp" />
</shape>
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 選取後使用淡綠色背景 -->
<solid android:color="#FFE8F5E9" />
<!-- 使用 Android 綠色粗框顯示選取狀態 -->
<stroke
android:width="3dp"
android:color="#FF3DDC84" />
<!-- 圓角 -->
<corners android:radius="12dp" />
<!-- 項目內容與邊框之間的距離 -->
<padding
android:left="8dp"
android:top="8dp"
android:right="8dp"
android:bottom="8dp" />
</shape>
Shape Drawable 可以使用 XML 定義背景顏色、邊框、圓角與內距,不需要另外製作 PNG 圖片。
參考資料:https://developer.android.com/guide/topics/resources/drawable-resource#Shape
為了讓整個網格項目可以切換背景,最外層的 LinearLayout 必須設定 ID:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/layout_itemRoot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
android:background="@drawable/grid_item_normal"
android:gravity="center"
android:orientation="vertical"
android:padding="8dp">
<!-- 顯示花朵縮圖 -->
<ImageView
android:id="@+id/imageView_itemPic"
android:layout_width="100dp"
android:layout_height="100dp"
android:contentDescription="@string/app_name"
android:scaleType="centerCrop"
android:src="@drawable/flower1" />
<!-- 顯示花朵名稱 -->
<TextView
android:id="@+id/textView_itemName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center"
android:maxLines="1"
android:text="花朵名稱"
android:textColor="#FF212121"
android:textSize="16sp" />
</LinearLayout>
新增的 layout_itemRoot 會一起加入 ViewHolder,讓 Adapter 可以根據 position 切換一般背景與選取背景。
參考資料:https://developer.android.com/reference/android/view/View#setBackgroundResource(int)
以下是今天重構後的完整 Adapter。資料來源已經從 MutableMap<String, Any> 改為 List<FlowerItem>,並加入選取狀態。
參考資料:https://developer.android.com/reference/android/widget/BaseAdapter
package com.example.listview6
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
/**
* GridView 使用的自訂 Adapter。
*
* DAY 24 重構重點:
* 1. 將 MutableMap<String, Any> 改成型別安全的 FlowerItem。
* 2. 移除字串 Key 與 as Int 強制轉型。
* 3. 加入 selectedPosition 記錄目前選取的位置。
* 4. 選取項目時切換背景 Drawable。
*
* @param myContext 提供版面配置與資源存取所需的 Context。
* @param listData GridView 顯示的花朵資料。
*/
class MyAdapter(
private val myContext: Context,
private val listData: List<FlowerItem>
) : BaseAdapter() {
/**
* 目前被選取的項目位置。
*
* -1 表示目前沒有任何項目被選取。
*/
private var selectedPosition: Int = -1
/**
* 回傳資料總筆數。
*/
override fun getCount(): Int {
return listData.size
}
/**
* 回傳指定位置的 FlowerItem。
*
* 將回傳型別直接指定為 FlowerItem,
* 呼叫端就不需要再進行強制型別轉換。
*/
override fun getItem(position: Int): FlowerItem {
return listData[position]
}
/**
* 回傳指定位置的項目 ID。
*
* 目前使用 position 作為項目 ID。
*/
override fun getItemId(position: Int): Long {
return position.toLong()
}
/**
* 更新目前選取的位置。
*
* 更新完成後呼叫 notifyDataSetChanged(),
* 要求 GridView 重新顯示目前畫面上的項目。
*/
fun setSelectedPosition(position: Int) {
selectedPosition = position
notifyDataSetChanged()
}
/**
* 回傳目前選取的位置。
*/
fun getSelectedPosition(): Int {
return selectedPosition
}
/**
* 建立或重複使用 GridView 單一項目的 View。
*/
override fun getView(
position: Int,
convertView: View?,
parent: ViewGroup?
): View {
val view: View
val holder: ViewHolder
if (convertView == null) {
// 沒有可用的舊 View,載入新的 item_layout
view = LayoutInflater
.from(myContext)
.inflate(
R.layout.item_layout,
parent,
false
)
// 建立 ViewHolder 並保存項目中的元件
holder = ViewHolder(
itemRootLayout = view.findViewById(
R.id.layout_itemRoot
),
itemImageView = view.findViewById(
R.id.imageView_itemPic
),
itemTextView = view.findViewById(
R.id.textView_itemName
)
)
// 將 ViewHolder 保存到 View 的 tag
view.tag = holder
} else {
// 重複使用 GridView 提供的舊 View
view = convertView
// 取回之前保存的 ViewHolder
holder = view.tag as ViewHolder
}
// 使用型別安全的 getItem() 取得資料
val item = getItem(position)
// 不再需要 item["name"] 或 toString()
holder.itemTextView.text = item.name
// 不再需要 item["pic"] as Int
holder.itemImageView.setImageResource(
item.pictureResId
)
// 根據目前位置是否被選取,切換項目背景
if (position == selectedPosition) {
holder.itemRootLayout.setBackgroundResource(
R.drawable.grid_item_selected
)
} else {
holder.itemRootLayout.setBackgroundResource(
R.drawable.grid_item_normal
)
}
return view
}
/**
* 保存 item_layout 中的畫面元件。
*/
private class ViewHolder(
val itemRootLayout: LinearLayout,
val itemImageView: ImageView,
val itemTextView: TextView
)
}
getItem() 現在直接回傳 FlowerItem,因此 Android Studio 可以辨識 name 與 pictureResId,也能提供自動完成與型別檢查。
參考資料:https://kotlinlang.org/docs/functions.html#return-values
MainActivity 也需要將資料清單改成 MutableList<FlowerItem>,點擊時則直接取得 FlowerItem,不再轉換 Map。
參考資料:https://developer.android.com/reference/android/widget/AdapterView
package com.example.listview6
import android.os.Bundle
import android.util.Log
import android.widget.GridView
import android.widget.ImageView
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 啟用 Edge-to-Edge 顯示
enableEdgeToEdge()
// 載入主畫面
setContentView(R.layout.activity_main)
// 避免畫面內容被狀態列或導覽列遮住
ViewCompat.setOnApplyWindowInsetsListener(
findViewById(R.id.main)
) { view, insets ->
val systemBars = insets.getInsets(
WindowInsetsCompat.Type.systemBars()
)
view.setPadding(
systemBars.left,
systemBars.top,
systemBars.right,
systemBars.bottom
)
insets
}
// 取得 GridView
val gridViewData =
findViewById<GridView>(R.id.gridView_id)
// 取得顯示選取結果的大圖
val imageViewPic =
findViewById<ImageView>(R.id.ImageView_pic)
// 讀取名稱陣列
val nameArray =
resources.getStringArray(R.array.name)
// 讀取圖片資源陣列
val picArray =
resources.obtainTypedArray(R.array.picture)
// 使用 FlowerItem 建立型別安全的資料清單
val listData =
mutableListOf<FlowerItem>()
/**
* indices 會取得陣列的有效索引範圍。
*
* 例如陣列有 6 筆資料,
* indices 就是 0..5。
*/
for (index in nameArray.indices) {
// 建立 FlowerItem 並加入資料清單
listData.add(
FlowerItem(
name = nameArray[index],
pictureResId = picArray.getResourceId(
index,
R.drawable.flower1
)
)
)
}
// TypedArray 使用完畢後回收
picArray.recycle()
// data class 會自動產生容易閱讀的 toString()
Log.d(
"MainActivity",
"listData = $listData"
)
// 建立自訂 Adapter
val adapter = MyAdapter(
myContext = this@MainActivity,
listData = listData
)
// 將 Adapter 設定給 GridView
gridViewData.adapter = adapter
// 設定 GridView 項目點擊事件
gridViewData.setOnItemClickListener {
parent,
view,
position,
id ->
/**
* 因為 MyAdapter.getItem() 已經回傳 FlowerItem,
* 所以可以直接呼叫 adapter.getItem(position),
* 不再需要將 Any 強制轉型成 MutableMap。
*/
val selectedItem =
adapter.getItem(position)
// 記錄目前選取位置並重新整理 GridView
adapter.setSelectedPosition(position)
// 更新下方圖片預覽
imageViewPic.setImageResource(
selectedItem.pictureResId
)
// 顯示目前選取的花朵名稱
Toast.makeText(
this@MainActivity,
"你選擇了:${selectedItem.name}",
Toast.LENGTH_SHORT
).show()
// 輸出點擊資訊,方便除錯
Log.d(
"MainActivity",
"position = $position, item = $selectedItem"
)
}
}
}
這裡將原本的 0..nameArray.size - 1 改為 nameArray.indices。後者可以直接取得陣列的有效索引範圍,可讀性較高,也不需要自行進行 size - 1 計算。
參考資料:https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.collections/indices.html
重構前需要進行強制轉型:
val item =
parent?.getItemAtPosition(position)
as MutableMap<String, Any>
imageViewPic.setImageResource(
item["pic"] as Int
)
重構後可以直接取得正確型別:
val selectedItem =
adapter.getItem(position)
imageViewPic.setImageResource(
selectedItem.pictureResId
)
新版程式不需要 as MutableMap<String, Any>,也不需要 as Int。如果誤寫不存在的欄位,例如 selectedItem.picture,編譯器會直接顯示錯誤,不會等到 App 執行後才當掉。
參考資料:https://kotlinlang.org/docs/typecasts.html
data class 還會自動提供 copy()。如果未來需要修改其中一筆資料,可以保留原本資料,只改變指定欄位:
val originalItem = FlowerItem(
name = "向日葵",
pictureResId = R.drawable.flower2
)
val updatedItem = originalItem.copy(
name = "黃色向日葵"
)
updatedItem 會保留原本的 pictureResId,只將名稱改成「黃色向日葵」。這種寫法在不可變資料、狀態管理及 Jetpack Compose 中特別常見。
參考資料:https://kotlinlang.org/docs/data-classes.html#copying
data class 也可以進行解構宣告:
val selectedItem = adapter.getItem(position)
val (
flowerName,
flowerPicture
) = selectedItem
解構之後便可以分別使用兩個值:
imageViewPic.setImageResource(
flowerPicture
)
Toast.makeText(
this,
"你選擇了:$flowerName",
Toast.LENGTH_SHORT
).show()
Kotlin 會為 data class 依照建構子的屬性順序產生 component1()、component2() 等函式,讓物件可以被解構成多個變數。
參考資料:https://kotlinlang.org/docs/destructuring-declarations.html
app
└── src
└── main
├── java
│ └── com.example.listview6
│ ├── MainActivity.kt
│ ├── MyAdapter.kt
│ └── FlowerItem.kt
│
└── res
├── drawable
│ ├── flower1.png
│ ├── flower2.png
│ ├── flower3.png
│ ├── flower4.png
│ ├── flower5.png
│ ├── flower6.png
│ ├── grid_item_normal.xml
│ └── grid_item_selected.xml
│
├── layout
│ ├── activity_main.xml
│ └── item_layout.xml
│
└── values
├── arrays.xml
└── strings.xml
將資料模型獨立放在 FlowerItem.kt,可以讓 Activity 專心負責畫面流程,Adapter 專心負責資料綁定,資料類別則專心描述資料結構。
參考資料:https://developer.android.com/topic/architecture
重構後的 App 外觀與昨天類似,但內部程式已經更加安全。點擊任一花朵後,該項目會顯示綠色外框,下方切換成對應的大圖,並透過 Toast 顯示花朵名稱。
參考資料:https://developer.android.com/reference/android/widget/Toast
這次重構最大的價值並不是新增多少畫面,而是讓錯誤可以在編譯階段被發現。程式規模愈大,明確的資料模型就愈重要。
參考資料:https://kotlinlang.org/docs/data-classes.html
今天的重點如下:
MutableMap<String, Any> 雖然彈性高,但缺乏型別安全。data class 可以明確定義資料欄位與型別。@DrawableRes 可標記圖片資源 ID。item.name 取代 item["name"]。item.pictureResId 取代 item["pic"] as Int。indices 取得陣列的有效索引。selectedPosition 保存目前選取位置。notifyDataSetChanged() 更新畫面。copy() 可以產生修改部分欄位的新物件。參考資料:https://kotlinlang.org/docs/data-classes.html
昨天學的是「如何把資料顯示在 GridView」,今天學的是「如何把資料設計得更安全」。這兩件事看起來差不多,但後者才是專案逐漸變大時,能否持續維護的重要關鍵。
參考資料:https://developer.android.com/topic/architecture
透過 FlowerItem,我們成功移除了容易出錯的字串 Key 與強制型別轉換,也加入點擊選取狀態。下一步可以繼續把這個傳統 GridView 範例升級成 RecyclerView+GridLayoutManager,正式進入目前更常見的清單架構。
參考資料:https://developer.android.com/develop/ui/views/layout/recyclerview